Skip to content

Support env-backed choices with choices env=...#548

Open
mustafa0x wants to merge 5 commits intojdx:mainfrom
mustafa0x:choices-env-pr
Open

Support env-backed choices with choices env=...#548
mustafa0x wants to merge 5 commits intojdx:mainfrom
mustafa0x:choices-env-pr

Conversation

@mustafa0x
Copy link

@mustafa0x mustafa0x commented Mar 18, 2026

Summary

  • add env support to choices so specs can source allowed values from an environment variable
  • honor Parser::with_env(...) without falling back to the process environment when a custom env map is provided
  • reject explicit values when choices env=... resolves to no values instead of silently disabling validation
  • preserve choices env=... when SpecArgBuilder::choices() is called after choices_env(...)
  • resolve env-backed choices for interactive shell completion at completion time
  • preserve choices env=... in help/docs instead of snapshotting live env-backed values into generated output
  • keep Fig suggestions literal-only so usage generate fig does not bake generation-time environment values into a static file

Example

arg "<env>" {
  choices env="DEPLOY_ENVS"
}

With DEPLOY_ENVS="foo,bar baz", valid values become foo, bar, and baz.

Implementation notes

  • choices now accepts an optional env property and preserves it when serializing back to KDL
  • env-backed values are split on commas and/or whitespace, with empty entries ignored
  • env-backed values are appended after literal choices and deduplicated against existing entries
  • parsing uses the caller-provided env map when Parser::with_env(...) is used; it only consults the process environment when no custom env map is provided
  • env-backed choices that resolve to no values now fail validation for explicit CLI input with a targeted error message instead of accepting any value
  • SpecArgBuilder::choices() now preserves any previously configured choices_env(...)
  • help/docs render literal choices as before and also surface the controlling env var for env-backed choices

Tests

  • unit tests for env choice splitting and deduplication
  • parser tests covering arg/flag validation with Parser::with_env(...)
  • parser test covering the empty env-backed choice set case
  • builder test covering .choices_env(...).choices(...) ordering
  • completion test covering env-backed choices
  • dump test covering KDL round-tripping

@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the application's argument parsing capabilities by allowing choices for arguments and flags to be dynamically sourced from environment variables. This provides greater flexibility in configuring the application's behavior based on the environment in which it is run.

Highlights

  • Environment Variable Choices: This PR introduces the ability to source argument and flag choices from environment variables using the choices env="..." syntax.
  • Consistent Resolution: The changes ensure that environment-backed choices are resolved consistently across parsing, help/documentation generation, shell completion, and Fig suggestions.
  • KDL Round-Tripping: The PR includes updates to preserve the env property when serializing choices back to KDL, ensuring proper round-tripping.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request successfully introduces support for environment-backed choices using the choices env=... syntax. The implementation is thorough, covering parsing, shell completion, documentation generation, and KDL serialization. The refactoring of choice validation into a dedicated function improves code organization and readability. New tests adequately cover the added functionality, ensuring correctness and robustness.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a84853de12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +52
let env_value = env
.and_then(|env_map| env_map.get(env_key).cloned())
.or_else(|| std::env::var(env_key).ok());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Parser::with_env isolated from process env

When callers use Parser::with_env to parse against a supplied environment map, this path still falls back to std::env::var, so choices env=... can be validated against ambient host variables that were never provided by the caller. That breaks the documented with_env contract in lib/src/parse.rs (“instead of the process environment”) and makes parsing nondeterministic in tests/monorepo tooling—for example, with_env(HashMap::new()) will still reject inputs if the parent process happens to export the same choice variable.

Useful? React with 👍 / 👎.

template: FigArg::get_template(&arg.name),
generators: FigArg::get_generator(&arg.name),
suggestions: arg.choices.clone().map(|c| c.choices).unwrap_or_default(),
suggestions: arg.choices.as_ref().map(|c| c.values()).unwrap_or_default(),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid baking current env into generated Fig suggestions

usage generate fig writes a static completion file, but this now resolves choices env=... immediately into suggestions. That means the generated script snapshots whatever environment was present when usage generate fig ran (or nothing at all if the variable was unset), so users of a checked-in/CI-generated Fig spec will see stale or empty suggestions instead of their own runtime env-backed choices.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link

greptile-apps bot commented Mar 18, 2026

Greptile Summary

This PR adds env=... support to the choices KDL node, allowing specs to source valid values from a named environment variable at parse and completion time. The implementation is thorough: env-backed choices are isolated from the process environment when Parser::with_env(...) is used, the env property is round-tripped through KDL serialization and preserved in all docs/help templates, and shell completion resolves env-backed values against the live process environment.

Key changes:

  • SpecChoices gains an env: Option<String> field; values_with_env correctly uses the custom env map when provided (and only falls back to std::env::var when no custom map is given), fixing the with_env isolation issue from a previous review cycle.
  • validate_choices is extracted as a standalone helper and applied consistently for user-provided values, env-var-applied values, and (new in this PR) default values — the last of which is a behaviour change that can cause specs combining choices env=... with a default "..." to fail when the backing env var is absent.
  • Completion adds has_explicit_choices to suppress the file-fallback when an env-backed choices constraint is defined but resolves to empty at completion time.
  • All docs templates (Tera/Markdown) now show both the literal choices list and the controlling env var name, so docs remain useful regardless of the runtime environment.

Confidence Score: 4/5

  • Safe to merge with the caveat that the new default-value validation against env-backed choices is a silent breaking change for specs that combine choices env=... with a hard-coded default.
  • The core logic is well-implemented, the previous-cycle issues (process-env leakage, unsafe remove_var, docs losing the env property) are all resolved, and the test coverage is solid. The one concern is that default values are now validated against choices for the first time, and for env-backed choices this can produce unexpected failures when the env var is absent — a scenario that is untested and not called out in the PR description.
  • lib/src/parse.rs — the new default-value validation loops (around lines 123–135 and the analogous flag paths) deserve a second look for the env-backed choices + default combination edge case.

Important Files Changed

Filename Overview
lib/src/spec/choices.rs Core change: adds env field to SpecChoices, implements values_with_env with correct isolation (uses custom map when provided, process env otherwise), KDL serialization updated, unit tests cover splitting, deduplication, and isolation.
lib/src/parse.rs Extracts validate_choices helper for env-aware validation and applies it to user-provided values, env-var-applied values, and default values; default-value validation is new behavior and could cause regressions for env-backed choices combined with hard-coded defaults when the env var is absent.
lib/src/spec/builder.rs Adds choices_env builder method and makes choices() preserve any pre-existing choices_env; builder tests confirm both orderings.
cli/src/cli/complete_word.rs Introduces has_explicit_choices flag to suppress file-completion fallback when an env-backed choices constraint is defined but resolves to empty at completion time; completions use choices.values() (process env) which is appropriate for interactive shell completion.
cli/tests/complete_word.rs Adds three completion tests: env-backed choices when env is set, empty completions when env is unset, and the default-subcommand root file-fallback regression test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Parser::parse] --> B{custom env provided?}
    B -- yes --> C[values_with_env with custom map]
    B -- no --> D[values_with_env falls back to process env]
    C --> E{env key in map?}
    E -- yes --> F[split on commas/whitespace, deduplicate]
    E -- no --> G[return only literal choices]
    D --> H{env var in process env?}
    H -- yes --> F
    H -- no --> G
    F --> I[combined literal + env values]
    G --> I
    I --> J{value in combined list?}
    J -- no --> K{is_help_arg?}
    K -- yes --> L[push help error, return Ok true]
    K -- no --> M{choices.env set AND values empty?}
    M -- yes --> N[bail: no choices resolved from env X]
    M -- no --> O[bail: expected one of ...]
    J -- yes --> P[Ok false - validation passed]

    Q[Shell Completion complete_arg] --> R[choices.values - uses process env]
    R --> S{env var set?}
    S -- yes --> T[return env-backed completions]
    S -- no --> U[return empty list]
    U --> V{has_explicit_choices?}
    V -- yes --> W[no file fallback]
    V -- no --> X[file completion fallback]
Loading

Comments Outside Diff (2)

  1. lib/src/parse.rs, line 123-135 (link)

    New default-value validation can silently break env-backed specs

    This PR introduces choice validation for default values — previously, defaults were never validated against choices. For literal-only choices this is unambiguously correct. However, for choices env="SOME_VAR" combined with a default "...", the new code validates the default at every parse call. If SOME_VAR is absent from the provided env map (or from the process env when no custom map is used), values_with_env returns [], and validate_choices immediately bails with "no choices resolved from env SOME_VAR" — even though the user never typed any input at all.

    A concrete failing scenario:

    let spec: Spec = r#"
        arg "<env>" {
            choices env="DEPLOY_ENVS"
            default "staging"
        }
    "#.parse().unwrap();
    
    // DEPLOY_ENVS is not set
    let result = Parser::new(&spec)
        .with_env(HashMap::new())   // empty custom map
        .parse(&["mytool".to_string()]);
    // → Err("no choices resolved from env DEPLOY_ENVS")
    // even though the user provided nothing

    The same applies to the flag default paths added below (around the flag.default[0] and arg.default loops). The analogous tests (test_parser_rejects_flag_default_value_not_in_choices_env) only test the case where the env var IS set but the default is not in it, so this scenario is untested.

    Consider skipping choice validation when the value being checked is a default rather than a user-provided or env-var-applied value, or at minimum documenting this as an intentional breaking change. The simplest guard would be to only run this validation loop when arg.choices.as_ref().and_then(|c| c.env.as_ref()).is_none() — i.e., skip env-backed choice validation for defaults since the valid set is dynamic.

  2. lib/src/parse.rs, line 716-737 (link)

    Ok(true) return is silently dropped for default-value callers

    validate_choices returns Ok(true) when it detects a help arg (--help/-h), as a signal to abort the current parse and surface a help error. The inline-parsing callers correctly act on this:

    if validate_choices(...)? {
        return Ok(out);
    }

    However, the default-value callers use the result purely for error propagation:

    validate_choices(...)?;  // Ok(true) is unwrapped but the bool is discarded

    If a default value happens to equal "--help" or "-h" and disable_help is not set, a help error is pushed onto out.errors and then parsing continues to insert the default — producing an inconsistent ParseOutput. In practice this is unlikely, but the function's return-type contract is subtly violated at every default-value call site.

    A cleaner approach would be to give validate_choices a narrower signature for the default-validation sites (e.g., return miette::Result<()> and never check is_help_arg there), or add an is_default: bool parameter to skip the help-arg special case for default values.

Last reviewed commit: "parse,complete: reva..."

@mustafa0x
Copy link
Author

Addressed review feedback in 5519652.

Changes made:

  • fixed SpecChoices::values_with_env(...) to avoid process-env fallback when Parser::with_env(...) provides a custom env map
  • reverted Fig suggestions to literal choices only (no env snapshot at generation time)
  • docs/help now preserve and render choices env: ... instead of resolving away env-backed source
  • removed unnecessary std::env::remove_var(...) calls from parser tests
  • added a guard test for no-fallback behavior with custom env maps

I could not run cargo test in this container because cargo is not available on PATH here.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55196528b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

lib/src/parse.rs Outdated
Comment on lines +649 to +650
let values = choices.values_with_env(custom_env);
if !values.is_empty() && !values.iter().any(|choice| choice == value) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject inputs when env-backed choices resolve to no values

For specs that use only choices env=..., values_with_env returns an empty list when that variable is unset, and this guard then skips validation entirely. That means any explicit CLI value is accepted even though the spec declared a constrained choice set, so a missing env var in CI or a custom Parser::with_env map silently disables the new restriction instead of failing fast.

Useful? React with 👍 / 👎.

Comment on lines 310 to 313
self.inner.choices = Some(SpecChoices {
choices: choices.into_iter().map(Into::into).collect(),
env: None,
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve choices_env when choices() is called later

The new builder API is order-sensitive here: choices() recreates SpecChoices with env: None, so .choices_env("DEPLOY_ENVS").choices(["local"]) drops the env-backed values completely. Callers building specs programmatically can easily hit this and end up with parser/completion behavior that no longer matches the intended combined literal+env choices.

Useful? React with 👍 / 👎.

@mustafa0x
Copy link
Author

Addressed the two additional review comments in c49eff4.

Updates:

  • validate_choices(...) now rejects explicit values when choices env=... resolves to an empty set, with a targeted error message
  • SpecArgBuilder::choices() now preserves an existing choices_env(...) configuration instead of replacing SpecChoices
  • added focused tests for both cases (parse empty-env behavior and builder ordering preservation)

I also updated the PR description to the v3 message.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c49eff4d7d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +268 to +270
if let Some(choices) = &arg.choices {
return Ok(choices
.choices
.iter()
.map(|c| (c.clone(), String::new()))
.filter(|(c, _)| c.starts_with(ctoken))
.collect());
let values = choices.values();
if !values.is_empty() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return no completions when env-backed choices resolve empty

When an arg/flag is constrained only by choices env=... and that variable is unset in the completion process, choices.values() is empty and this branch falls through instead of short-circuiting. In complete_word(), an empty result is then treated as “no known completion” and we fall back to complete.run/filesystem suggestions, so pressing Tab for examples/env-choices.usage.kdl with DEPLOY_ENVS unset will suggest arbitrary paths even though the parser rejects every value. Treating the presence of arg.choices as authoritative here avoids advertising impossible completions.

Useful? React with 👍 / 👎.

@mustafa0x
Copy link
Author

Addressed the latest completion feedback in de8e0d1.

Changes:

  • completion now treats explicit arg.choices as authoritative and skips filesystem fallback even when resolved values are empty (e.g. choices env=... unset)
  • added regression test: complete_word_choices_from_env_unset_returns_empty

Verification run:

  • mise x -- cargo test -p usage-cli complete_word_choices_from_env (passes, including the new test).

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de8e0d15a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +504 to +512
if validate_choices(
spec,
&out.cmd,
&mut out.errors,
"arg",
&arg.name,
&w,
arg.choices.as_ref(),
custom_env,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate env/default-populated values against resolved choices

The new choice check only runs while consuming argv words here. Parser::parse still fills missing args/flags from arg.env, flag.env, and defaults afterwards without rechecking them, so a spec like arg <env> env="CURRENT_ENV" { choices env="DEPLOY_ENVS" } will accept CURRENT_ENV=prod even when DEPLOY_ENVS=dev,staging. That means the new choices env=... constraint is bypassed whenever the value comes from env/defaults instead of the command line.

Useful? React with 👍 / 👎.

if let Some(default_cmd) = spec.cmd.find_subcommand(default_name) {
// Include completions from default subcommand's first arg
if let Some(arg) = default_cmd.args.first() {
has_explicit_choices = has_explicit_choices || arg.choices.is_some();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep root file fallback when default subcommand has choices

This marks has_explicit_choices based on the default subcommand's first arg even when the active completion target is still a root positional arg. In CompleteWord::complete_word, that suppresses the file fallback at lines 161-165, so a command shaped like cmd <path> loses filesystem suggestions as soon as its default_subcommand happens to start with a choices-constrained arg.

Useful? React with 👍 / 👎.

@mustafa0x
Copy link
Author

Addressed the latest two review comments in 82adee1.

Fixes:

  • revalidated env/default-populated arg/flag values against choices during Parser::parse (not only argv-consumed values)
  • kept root path fallback behavior when default-subcommand arg has choices by narrowing when explicit choices suppress fallback

Regression tests added:

  • test_parser_rejects_arg_env_value_not_in_choices_env
  • test_parser_rejects_flag_default_value_not_in_choices_env
  • complete_word_default_subcommand_choices_do_not_block_root_file_fallback

Targeted verification:

  • mise x -- cargo test -p usage-lib test_parser_rejects_arg_env_value_not_in_choices_env
  • mise x -- cargo test -p usage-lib test_parser_rejects_flag_default_value_not_in_choices_env
  • mise x -- cargo test -p usage-cli complete_word_default_subcommand_choices_do_not_block_root_file_fallback

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

if let Some(arg) = default_cmd.args.first() {
choices.extend(self.complete_arg(

P2 Badge Mark default-subcommand choices before falling back to files

When the root command has no positional arg of its own but default_subcommand does, this branch appends the default subcommand’s first-arg completions without updating has_explicit_choices. If the current token matches none of those choices, line 161 falls back to filesystem suggestions even though parse() will route that word into the default subcommand and reject every path. A spec like default_subcommand "run" with cmd "run" { arg "<env>" { choices "prod" "staging" } } will therefore suggest files for tool x<Tab> instead of returning no completions.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 123 to +127
if !arg.default.is_empty() {
for default_value in &arg.default {
validate_choices(
self.spec,
&out.cmd,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate only the default value a scalar arg can emit

For non-variadic args, parse() still returns only arg.default[0], but this new loop rejects every entry in arg.default before we reach that branch. Specs built with SpecArgBuilder::default_values([..]) (which the builder tests already allow for scalar args) will now start failing choice validation on later defaults that are never observable at runtime—for example, default_values(["dev", "prod"]) plus choices ["dev"] now errors even though parsing would still yield "dev". The same regression is repeated in the nested flag-arg default block below.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant